Repository navigation
Make live view usable on touch devices - #448
masnwilliams wants to merge 25 commits into
Conversation
Add a gesture layer for touch input: tap clicks, one-finger swipes scroll with momentum, long-press right-clicks or drags, and two-finger pinch zooms and pans the video locally. Raise the soft keyboard from the tap when the remote cursor is a text cursor, type Android input and composition events, and send Shift for upper case letters from soft keyboards. Replace the small keyboard icon with a labeled button inside the safe area and fix the layout below 360px and under mobile browser toolbars.
…sor shapes Lift the keyboard button by the height the visual viewport loses to an overlaid soft keyboard, and only apply the classification of the newest cursor image.
The keyboard button is positioned inside the player, so lift it by the part of the player the visual viewport no longer shows.
Replace the fixed keyboard button with a draggable control that sits in a band outside the video: below it when there is vertical room, in a strip beside it when there is horizontal room, shrinking the video by up to 10% to make one. Only when there is no room does it overlay the stream as an edge-snapping handle that lets taps through while faded. The control fades when idle in every mode and its position is remembered.
Dragging kept a 44px assumption, so the wider bottom-band control jumped under the finger and landed away from the drop point. Measure the control, keep the finger's offset while dragging, and keep it out of the top and bottom safe areas.
Over a real network the cursor update for a tap usually arrives after the finger lifts, so the keyboard could only be offered through the chip. Track where the current cursor shape was seen and decide inside the tap when it lands on that spot again, such as a second tap on the same field.
A cursor image could be attributed to a newer pointer position while an update for an earlier one was still in flight, and the wait timeout used the latest pointer instead of the tap. Attribute images only when no earlier update can be pending, and pin a timed-out tap to its own point while the pointer is still there.
Pages often show another cursor before the I-beam after a click, and the first update after a tap was taken as the answer, so taps on such fields never offered the keyboard. Any text cursor within the window now offers it, only the end of the window means the tap was not on text, and the same-spot shortcut remembers where a text cursor was seen.
Neko sends no cursor image when the shape does not change, so a tap on another text field while the I-beam was showing ended the wait with no update and dismissed the keyboard. Treat an unchanged I-beam at the end of the wait as text.
|
We ran into this with a real user on an iPhone (iOS Safari), on the live view that production serves today (bundle app.416afd11.js, which still has Tapping a field in the stream focuses the remote field, but the iOS keyboard never opens, and the user could not find any way to type. The small keyboard icon at the bottom-right does open it. In an emulated iPhone (Playwright WebKit, iPhone 15 Pro) the text then arrived wrong: "Hello" came through as "hello", "!" was lost, and text from autocorrect or predictive suggestions (input events only) was dropped. On the default 1920x1080 window the stream is about 393x221 px on a phone, so the fields are hard to hit too. CDP Our case: an agent drives the browser, and a person on a phone types a login and a 2FA code in the live view. This PR (tap-to-type, input/composition handling, Shift for capitals, the 16 px font) covers what we hit. Happy to test a build on a physical iPhone if that helps. |
iOS only shows the keyboard for focus() inside a gesture, and over a real network the I-beam usually arrives after the finger lifts, so auto mode fell back to the Tap to type button. Focus the overlay inside the tap with inputmode=none and remove inputmode when a text cursor arrives, which WebKit treats as a request to show the keyboard. Other browsers focus late instead. A tap under an I-beam that is already showing now raises the keyboard at the end of the window, the remembered text spot is measured in screen pixels, and the button is kept for a text cursor that arrives after the window.
A text cursor or the end of the window for an earlier tap could raise the keyboard during a scroll, pinch or drag that started before the cursor arrived. A new touch now cancels the wait and releases a held focus.
|
Thanks for the detailed report @pedro-pscunha — and for offering to test on a physical iPhone. Good news: we've already validated the keyboard fix on a real iPhone against this build. Tapping a field now raises the iOS keyboard in The PR is in review now. Appreciate the offer to test — if anything looks off once this ships, we'd love to hear it. |
Sayan-
left a comment
There was a problem hiding this comment.
direction makes sense to me. one question on the protocol choice, and a few ux quirks worth checking.
questions
i see the client-only scope and the concern about consistent browser touch support. did you evaluate migrating the legacy input path to neko’s existing v3 touch messages? the pinned server already handles touch begin/update/end. client, server touch handlers
ux quirks
- while zoomed, double-tapping to reset sends the first tap to the remote page, potentially activating a link underneath. is that intended? code
- re-enabling input before scroll momentum ends resumes the old swipe without another touch. should disabling input end the gesture? code
- on devices classified as touch/coarse-pointer, the added Shift can turn physical-keyboard letters lowercase when Caps Lock is already active remotely. should this account for Caps Lock? code
- in Chromium, clicking “Hide keyboard” with a mouse/trackpad blurs the input before the toggle runs, so it focuses the input again. finger taps avoid this. code
cast=1hides the other controls but can still show “Tap to type.” should it hide that prompt too? code- when the keyboard overlays the view, the new pan limits can leave a bottom-fixed field hidden even after panning fully upward. should they account for the covered area? this is an existing keyboard-visibility gap the new panning does not resolve. code
- the faded overlay button intentionally lets taps through, but someone trying to open the keyboard can activate a link underneath. could the first tap wake the button without clicking the page? code
|
Hi! I am very excited about this change and tested it on a physical iPhone (iOS 27 Safari. Thank you for this, it's a big step up. Works: tapping a text field opened the iOS keyboard on the first tap, and typed text reached the remote field. Three UX I hit:
Happy to re-test on device when you push changes. |
- Stop a fling whose scroll handler stopped it, and end the page's part of a touch (including a fling) when input stops being enabled, instead of checking for input in every gesture handler. - Decide at touch begin whether the page takes the touch. Without input only a pinch zooms locally. Drop double-tap to reset the zoom, so two quick taps while zoomed are two clicks; the Reset chip stays. - Let a faded keyboard handle over the stream take its own touches; the first one only wakes it. A mouse click on it no longer blurs first. - Move the tap keyboard decisions into a state machine in utils/tap-keyboard.ts with an injected clock, and drop the pointer timing heuristic for the remembered text spot. - Send key events and soft keyboard text through one TextInput path with a single Shift decision that respects Caps Lock, and replace the 50ms dedupe windows with flags. - Hide the Tap to type chip with the other controls, read cursor images only on touch devices, and keep zoom pan limits out of the area an overlaid keyboard covers.
Losing input mid-drag ended the gesture without onDragEnd, so the remote button pressed at drag start was never released.
iOS reports its soft keyboard's Shift and Caps Lock as key events of their own, and the Guacamole keyboard releases every key on iOS right after pressing it. The local Caps Lock check skipped Shift for letters typed after a soft Shift, the soft Caps Lock toggled the remote's Caps Lock, and the immediate release cleared the dedupe flag before the letter's input event, so the letter could be typed twice. On touch devices, never send Caps Lock, and send Shift around a letter when the Shift held on the remote (tracked from the keys sent) and the remote's last known Caps Lock would give it the other case. Only a native keyup ends a key's claim on the next input event, and modifier keys make no claim.
A touch device cannot see the remote's Caps Lock, and touch input never toggles it, so a Caps Lock left on by an earlier session inverted every letter. Turn it off when the keyboard opens and decide each letter's case with Shift alone; the other lock keys are left as they are.
…and park the pointer after touch - While the soft keyboard is open, a frame that only gets shorter keeps the video's size; the video moves up so the last tap stays above the keyboard, and refits when the keyboard closes. - Add a Paste button to the keyboard control while the keyboard is open. It reads the phone clipboard inside the tap and types the text through the soft keyboard path, with a notice when access is denied or unavailable. A native paste on a touch device is typed the same way, and the overlay offers one-time-code AutoFill. - The remote pointer is drawn into the video, so after touch input goes idle move it to the bottom right corner, where it is almost entirely off screen. Mouse input brings it back.
The on-screen position moves during the pan's transition, so recomputing from it on each viewport event drifted past the pan the last tap needed.
…or images in order A tap that moves the pointer while the previous tap's cursor is still pending can receive that tap's I-beam. Treat a text cursor in that window as unsettled: it no longer raises the keyboard on its own, and if no later cursor replaces it by the end of the wait, offer the Tap to type button. Safari delivers data channel messages as Blobs by default, and converting them asynchronously could apply an older cursor image after a newer one. Ask for ArrayBuffers instead.
A non-text tap left the keyboard state in late, so the next field tap treated its own I-beam as possibly stale and showed Tap to type. Only a tap whose wait is still running can have a cursor in flight, and a stale mark now applies only to the touch it arrived during.
…he pointer, and add Copy - Remove the Tap to type button and the chip mode. If the cursor guess misses a field, the keyboard button opens the keyboard. - After Enter with the keyboard open on an I-beam, close the keyboard if the cursor under the last tap is no longer an I-beam a second later (a submitted form or a new page); a chat box keeps its I-beam and the keyboard. - Leave the remote pointer where the touch left it. The legacy stream draws the pointer into the video, so the client cannot hide it. - Add a Copy button to the keyboard control. It starts a clipboard write inside the tap with the text pending, sends Ctrl+C, and fills in the text from the next remote clipboard update, with a notice when nothing was copied or the write is refused.
The remote clipboard update that fills in the Copy control's text also triggered the automatic clipboard sync, whose writeText could replace the pending write and fails outside a tap on iOS. Skip the sync while a Copy write is pending.
… after a selection, and fall back to the clipboard panel - While the soft keyboard is open, a row above it has Esc, Tab, a one-shot Ctrl that stays latched until the next key, the arrows, and Paste. Paste leaves the side controls. - Paste types text up to 32 characters, which also works in fields that block pasting and in one-box-per-character code fields. Longer text is set as the remote clipboard and pasted with Ctrl+V once neko echoes the clipboard back, and typed if it does not. - Copy shows on the side controls for a while after a long-press drag, instead of all the time, and a tap withdraws it. - On touch devices neko's clipboard panel opens when the phone clipboard cannot be read or written, at the top of the view with Paste into page and Close buttons.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 5d6e96e. Configure here.
The panel sits at the top of the player, which is moved up while the keyboard is open, so it could open above the screen.

Summary
Live view is hard to use from a phone or tablet: a one-finger swipe is sent as a mouse drag (it selects text instead of scrolling), a desktop-sized stream is too small to read or hit, and the soft keyboard only opens from a small icon. This PR adds a touch layer to the live view client. It is client-only: the remote browser still receives the same mouse, wheel and key messages it does today.
Changes
Gestures (
src/utils/touch-gestures.ts)src/utils/zoom-pan.ts), up to 3x the fitted size, or 1.5x the stream's native pixel size for large streams (at most 8x). The zoomed video can fill the letterbox bars, but not the part an overlaid soft keyboard covers. A "2.5× · Reset" chip shows while zoomed and resets it. Double-tap does not reset the zoom, so two quick taps while zoomed are two clicks. Pointer coordinates are mapped through the zoom, so taps land on the right remote pixel.Keyboard
touchstart.focus()inside the user gesture, and over a real network the cursor update usually arrives after the finger lifts. So on iOS, a tap whose cursor is not known yet focuses the overlay insidetouchendwithinputmode="none", which shows no keyboard. When a text cursor arrives,inputmodeis removed, and WebKit then shows the keyboard for the element that is already focused (WebKit'sfast/forms/ios/inputmode-removing-none.htmllayout test covers this). If no text cursor arrives, the overlay is blurred when the window ends. Other browsers allow a laterfocus()after a user gesture, so they focus when the cursor arrives.src/utils/keyboard-pan.ts).src/utils/cursor-shape.tsfor the cursor classification andsrc/utils/tap-keyboard.tsfor the decisions.inputand composition events, which the Guacamole keyboard ignores. These are now typed, including composition updates and backspace.input-only text go through one path (TextInputinsrc/utils/text-input.ts). Aninputevent that follows a key the Guacamole keyboard already sent is skipped until that key's native keyup, and so is the repeat some keyboards send aftercompositionend.src/components/touch-keys.vue). Ctrl is one-shot: it stays visibly latched until the next key, from the row or the soft keyboard, and goes with that key only. The row's keys act ontouchendand never take focus, so the keyboard stays open.navigator.clipboard.readText, which iOS confirms with its own Paste bubble). Text up to 32 characters on one line is typed, which also works in fields that block pasting and in code fields with one box per character. Longer text is pasted the way neko's desktop path does it: it is set as the remote clipboard (control/clipboard, the only clipboard message neko's legacy protocol has), and Ctrl+V is sent once neko reports the new clipboard back. If that report does not come or holds other text, the text is typed instead. A native paste on a touch device goes the same way.navigator.clipboard.write([new ClipboardItem({ 'text/plain': promise })])inside the tap, sends Ctrl+C, and fills the promise from the next remote clipboard update over neko's clipboard sync, so iOS's user-gesture rule holds. The automatic clipboard sync skips its own write while a Copy write is pending.src/components/touch-controls.vue) that stays off the stream whenever it can:localStorage, the control moves up above an overlaid soft keyboard (usingvisualViewport), and it shows on any touch device rather than only below 768px or with?embed.autocapitalizeandautocorrectoff,autocomplete="one-time-code"on touch devices (off on desktop) so iOS can offer a code it received, and a 16px font so iOS does not zoom the page when it focuses.Layout
.neko-mainno longer hasmin-width: 360px, which pushed the controls off-screen in narrow embeds.100dvh, so the bottom controls are not hidden under mobile browser toolbars.viewport-fit=covermakes the safe-area insets apply.URL option
?tapKeyboard=auto(default): raise the keyboard when the tap lands on a text cursor.always: raise it on every tap.off: keyboard button only.Desktop mouse and keyboard handling is unchanged. The new input paths only run for touch events, or on touch devices (Shift wrapping,
inputhandling and reading cursor images). The touch-only branch infocusOverlayis removed because touch no longer synthesizes mouse events.Testing
bun test tests: 153 pass, includingtests/touch-input.test.ts(gesture classification, fling and its cancellation, a cancelled drag releasing its button, long-press right-click and drag, touchcancel, touches the page does not take, zoom math and clamping, keysym mapping, soft keyboard text with key-event and composition dedupe, and an iOS soft keyboard replay through the real Guacamole keyboard covering a Shift tap, soft Caps Lock on and off, and every combination of modifier flags on the letter events),tests/touch-controls.test.ts(band, strip, shrink and overlay layout; drag and snap; stored position; a faded handle's first tap),tests/tap-keyboard.test.ts(tap keyboard decisions in every mode, driven through the state machine with a fake clock) andtests/video-input.test.ts(two taps while zoomed, ending a touch when input stops). Lint shows no new warnings compared withmain, andnpm run buildsucceeds.contextmenu.touchendwithinputmode="none", andinputmodewas removed when the I-beam arrived (about 170ms) or at the end of the window (about 900ms) for a tap under an existing I-beam. A tap within the remembered spot focused inside the tap. Text typed afterwards reached the remote textarea, and tapping the blank area blurred the overlay with no button. With 1000ms added, the button appeared at about 1020ms. With Pixel 7 emulation the overlay was focused when the I-beam arrived instead of inside the tap. A swipe started 40ms after a tap on the field, before the I-beam arrived, released the held focus at itstouchstartand showed neither the keyboard nor the button. Before this change, the button appeared during the swipe.inputevents, IME composition with a keyCode 229 backspace, and key events with capitals all arrived correctly.contextmenuon the remote page, and long-press then drag across a paragraph selected text.Input.insertTextof "Hello World" after tapping a textarea arrived as "Hello World".navigator.platformset to iPhone so the Guacamole keyboard ran with its iOS quirks, typed "Hello ABc" into Google's search box. That held whether the letter events reported no modifiers, CapsLock or Shift, and with the remote's Caps Lock left on beforehand (it was off afterwards). The previous version typed each letter twice, in the wrong case when the letters reported CapsLock or Shift, and toggled the remote's Caps Lock.input-event text was dropped, and capitals arrived in lower case.Not verified yet:
inputmode="none", real Gboard behaviour, and timing over cellular networks.Known gaps
automode tapping plain paragraph text can open the keyboard.?tapKeyboard=offleaves the keyboard to the button.legacystream, captured withshow-pointer=true, so the client cannot hide it. Hiding it on touch needs a neko change.Future work
maxTouchPoints0 and a fine pointer.Note
Medium Risk
Large client-side change to remote input, clipboard, and WebRTC data handling on touch devices; desktop paths are gated but regressions in hosting/control or clipboard sync are possible.
Overview
Adds a touch-first interaction layer to the chromium-headful live view client so phones and tablets can scroll, zoom, type, and use clipboard without treating every swipe as a mouse drag.
Gestures and zoom: Touch handling is replaced with a gesture recognizer (tap, long-press right-click/drag, one-finger scroll with fling, pinch zoom). The stream can be pinch-zoomed locally with coordinate mapping so remote clicks stay accurate; layout uses
100dvh,viewport-fit=cover, and relaxedmin-widthfor narrow embeds.Keyboard and text: Soft keyboard opens from remote cursor shape (I-beam detection over the data channel) with iOS-specific focus/
inputmodetiming (tap-keyboard). Android/iOS text goes through a newTextInputpath (composition, Shift for capitals, Caps Lock suppressed on touch). A floating keyboard/copy control and an extra keys row (Esc, arrows, Paste, latched Ctrl) sit above the keyboard; the video pans up when the keyboard covers it (visualViewport).Clipboard: Touch Copy/Paste works within Safari gesture rules (pending
ClipboardItemwrites, remote clipboard sync); the clipboard panel gets a touch layout with “Paste into page”. The client emitscursorandclipboardevents and sets the data channel toarraybufferfor cursor images.Desktop mouse/keyboard paths are largely unchanged; new logic gates on touch detection and touch events.
Reviewed by Cursor Bugbot for commit 8903604. Bugbot is set up for automated code reviews on this repo. Configure here.